Setting up the presentation....
In [1]:
import nb_assets
nb_assets.load_magics()
In [2]:
import numpy as np
import tornado
In [3]:
from ipywidgets import widgets
from traitlets import Unicode, Bool, Dict, List
In [4]:
import bokeh
from bokeh.plotting import *
from bokeh.embed import components
output_notebook()
In [100]:
%%html
<style>
.container.slides .celltoolbar, .container.slides .hide-in-slideshow, #exit_b, #help_b {
display: None ! important;
}
section#slide-0-0 {
}
// Some styles to make presentation better for video capture
.container.slides {
width: 1300px;
}
.container.slides .text_cell_render{
font-size: 2em;
}
.container.slides .cell .input {
font-size: 1.3em;
}
</style>
In [95]:
from IPython import display
from IPython.core.magic import register_cell_magic
@register_cell_magic
def html_nocode(line, cell):
hide_code_in_slideshow()
display.display_html(cell, raw=True)
def hide_code_in_slideshow():
import binascii
import os
uid = binascii.hexlify(os.urandom(8)).decode()
html = """<div id="%s"></div>
<script type="text/javascript">
$(function(){
var p = $("#%s");
if (p.length==0) return;
while (!p.hasClass("cell")) {
p=p.parent();
if (p.prop("tagName") =="body") return;
}
var cell = p;
cell.find(".input").addClass("hide-in-slideshow")
});
</script>""" % (uid, uid)
display.display_html(html, raw=True)
The browser-side widget is relatively simple. The corresponding Python declaration of the widget (see further down) defines a few traits, which are automatically synced by the widget system. So when the "render" method is called, the "scripts" and "divs" traits are already filled with the initial Bokeh data.
The "divs" dictionary contains only one entry, "plot", which is inserted into the widget's DOM element. The "scripts" trait contains a string with a javascript tag. This is appended to the document, and is executed automatically by the browser some time after "render" is finished. That is why some other setup work needs to be done later inside a timeout.
The browser-side and python-side widget instances can communicate with each other either by changing the traits (which are then synced to the other side) or by custom messages. The browser-side widget in this case responds to only one such message, to replace the contents of a bokeh data source.
On the other hand, it sends keypress events to the Python-side.
In [114]:
%%coffeescript
requirejs.undef('snake')
define('snake', ["jquery", "widgets/js/widget"], ($, widget)->
console.log("loading widget")
SnakeWidget = widget.DOMWidgetView.extend({
render: ->
SnakeWidget.__super__.render.apply(this, arguments)
html = @model.get("divs")["plot"]
html = "<div tabindex='1'>"+html+"</div>"
@setElement($(html))
setEvents= ()=>
$(@el).keydown($.proxy(@onKeypress, @))
setJs= ()=>
js = @model.get("scripts")
$(js).appendTo(document.body)
setEvents(setEvents, 1)
setTimeout(setJs, 1)
@model.on('msg:custom', (msg)=> @handle_custom_message(msg))
onKeypress: (event)->
# only capture the relevant keys
if event.which in @model.get('capture_codes')
event.preventDefault()
event.stopPropagation()
@send({'msg_type':'key', 'which': event.which})
handle_custom_message: (data)->
switch
when (data.custom_type == "replace_bokeh_data_source")
ds = Bokeh.Collections(data.ds_model).get(data.ds_id)
ds.set($.parseJSON(data.ds_json))
ds.trigger("change")
})
return {
SnakeWidget: SnakeWidget
}
)
The Widget class creates the Bokeh models for the game world. These are then serialized into their scripts and components parts and implicitly send over to the Browser. The data sources are kept as class members to change them later.
A "game loop" is achieved by using Tornado callbacks. The IPython Kernel running this codes uses PyZMQ to communicate with the Jupyter Notebook Server, which in turn communicates with the Browser. However, PyZMQ uses a Tornado event loop internally, which is already running by the time the widget starts up.
Bokeh's ColumnDataSource model contains columns of lists. Bokeh's "glyphs" (think of a glyph as a shape) use the rows of this table to draw shapes on the plot. So every row of the data source is another shape. You can specify which columns are used for what glyph parameter.
To update a data source the Python widget instance needs to send a custom message to its corresponding Javascript instance. Every Bokeh data source has its own Id, which is consistent between Python and Javascript, and that is why the updating is so easy.
The "Gameover" Text is another special case, where only the "text_alpha" parameter is dynamically set, and only one "row" means only one instance of the text.
In [115]:
# Key code constants
LEFT_ARROW = 37
UP_ARROW = 38
RIGHT_ARROW = 39
DOWN_ARROW = 40
class SnakeWidget(widgets.DOMWidget):
""" Python-side class for the SnakeWidget. """
_view_module = Unicode('snake', sync=True)
_view_name = Unicode('SnakeWidget', sync=True)
scripts = Unicode(sync=True)
divs = Dict(sync=True)
capture_codes = List(sync=True)
running = Bool(False, sync=True)
def __init__(self, *args, **kwargs):
widgets.DOMWidget.__init__(self,*args, **kwargs)
self.on_msg(self._handle_custom_msg)
self.pos_x = 0
self.pos_y = 25
self.direction = RIGHT_ARROW
self.tail = np.repeat(np.array([(self.pos_x, self.pos_y)]), 8,axis=0)
self.food = np.random.randint(1,50, (10,2))
self.grid_size = 50
self.plot = figure(title = "Snake", x_range=(0, self.grid_size),
y_range=(0, self.grid_size),
tools=[])
self.tail_ds = ColumnDataSource({'x': self.tail[:,0], 'y': self.tail[:,1]})
self.plot.rect(source=self.tail_ds, x="x", y="y", width=1, height=1, dilate=True)
self.food_ds = ColumnDataSource({'x': self.food[:,0], 'y': self.food[:,1]})
self.plot.circle(source=self.food_ds, x="x", y="y", radius=0.45, color="red")
self.gameover_ds = ColumnDataSource({'alpha':[0]})
self.plot.text(source=self.gameover_ds, text=["Game Over"],
text_font_size=["30pt"],
text_align="center",
x=[self.grid_size/2], y=[self.grid_size/2],
text_alpha="alpha", color="red")
scripts, divs = components({'plot': self.plot})
self.scripts = scripts
self.divs = divs
self.capture_codes = [LEFT_ARROW, UP_ARROW, RIGHT_ARROW, DOWN_ARROW]
def replace_bokeh_data_source(self, ds):
self.send({"custom_type": "replace_bokeh_data_source",
"ds_id": ds.ref['id'],
"ds_model": ds.ref['type'],
"ds_json": bokeh.protocol.serialize_json(ds.vm_serialize())
})
def _handle_custom_msg(self, content, extra=None):
if content['msg_type']=='stop':
self.stopped = True
if content['msg_type'] == 'key':
w = content['which']
self.handle_key(w)
def handle_key(self, w):
if w in [LEFT_ARROW, UP_ARROW, RIGHT_ARROW, DOWN_ARROW]:
self.direction = w
def iterate(self):
if self.direction == RIGHT_ARROW:
self.pos_x += 1
elif self.direction == LEFT_ARROW:
self.pos_x -= 1
elif self.direction == UP_ARROW:
self.pos_y += 1
elif self.direction == DOWN_ARROW:
self.pos_y -= 1
position = np.array((self.pos_x,self.pos_y))
if (self.tail == position).all(axis=1).any() \
or (position==0).any() \
or (position==self.grid_size).any():
self.running = False
self.gameover_ds.data['alpha'] = [1]
self.replace_bokeh_data_source(self.gameover_ds)
return
self.tail = np.concatenate((self.tail,(position,)), axis=0)
if (self.food == position).all(axis=1).any():
self.food = np.array([r for r in self.food if (r!=position).any()])
self.food = np.concatenate((self.food, np.random.randint(1,self.grid_size,
(1,2))), axis=0)
self.food_ds.data['x'] = self.food[:,0]
self.food_ds.data['y'] = self.food[:,1]
self.replace_bokeh_data_source(self.food_ds)
else:
self.tail = self.tail[1:,:]
self.tail_ds.data['x'] = self.tail[:,0]
self.tail_ds.data['y'] = self.tail[:,1]
self.replace_bokeh_data_source(self.tail_ds)
def timer(self):
if not self.running:
return
loop = tornado.ioloop.IOLoop.current()
self.iterate()
loop.call_later(0.25, self.timer)
def run(self):
self.running = True
self.timer()
def stop(self):
self.running = False
In [108]:
w = SnakeWidget()
display.display(w)
w.run()
In [89]:
w.stop()
In [ ]: